import { decode, encode } from 'html-entities';
import { CSSProperties } from '.';

const monoTag = /^(br|hr|img|input|param|meta|link)$/i;

function encodeHTML(str: string | undefined): string {
	return encode(str ?? '').replaceAll(String.fromCharCode(160), '&nbsp;');
}

class KnnoCollection extends Array<KnnoElement> {
	constructor(...arr: Array<KnnoElement>) {
		super(...arr);
	}
	namedItem(name: string): KnnoElement | null {
		return this.find((item) => item.getAttribute('name') == name) ?? null;
	}
	item(index: number): KnnoElement | null {
		return this[index];
	}
}

class KnnoList extends Array<KnnoElement> {
	item(index: number): KnnoElement {
		return this[index];
	}
}

type KnnoAttr = { name: string; value: string };

type KnnoClassList = {
	add(...name: string[]): void;
	contains(name: string): boolean;
	item(index: number): string;
	remove(...name: string[]): void;
	[Symbol.iterator]: () => Iterator<string>;
	toggle(name: string, force?: boolean): boolean;
};

class KnnoAttrs extends Map<string, string> {
	getNamedItem(qualifiedName: string): KnnoAttr | null {
		const value = this.get(qualifiedName);
		if (value) {
			return { name: qualifiedName, value };
		} else {
			return null;
		}
	}
	item(index: number): KnnoAttr | null {
		const item = Array.from(this.entries())[index];
		return item ? { name: item[0], value: item[1] } : null;
	}
	removeNamedItem(qualifiedName: string): KnnoAttr {
		const item = this.getNamedItem(qualifiedName);
		if (item && this.delete(qualifiedName)) {
			return item;
		} else {
			throw new Error('Not Found');
		}
	}
	setNamedItem(attr: KnnoAttr): KnnoAttr | null {
		const item = this.getNamedItem(attr.name);
		this.set(attr.name, attr.value);
		return item;
	}
}

function setAttributes(elem: KnnoElement, attr: string) {
	const parser = /(?<key>[^=\s"']+)(?:=(?<value>"[^"]*"|'[^']*'|[^\s"']+))?/gm;
	let m = parser.exec(attr);
	while (m) {
		const g = m.groups as { key: string; value?: string };
		let value = g.value ?? g.key;
		if (value[0] === '"' || value[0] === "'") {
			value = value.slice(1, value.length - 1);
		}
		elem.setAttribute(g.key, decode(value));
		m = parser.exec(attr);
	}
}

type KnnoStyle = {
	[index in keyof CSSProperties]: string;
} & {
	setProperty(propertyName: string, value?: string, priority?: string): void;
};

class KnnoElement {
	readonly baseURI = '';
	readonly isConnected = false;
	private _attributes?: KnnoAttrs;
	private _childNodes?: KnnoList;
	private _textContent?: string;
	private _style?: Map<string, string>;

	nodeValue: string | null = null;
	parentElement: KnnoElement | null = null;

	value: string | null = null;

	private getClassArray() {
		return (this.getAttribute('class') ?? '').split(/\s+/).filter((s) => !!s);
	}

	private _classList = {
		add: (...name: string[]) => {
			const arr = this.getClassArray();
			this.setAttribute('class', arr.concat(name.filter((n) => !arr.includes(n))).join(' '));
		},
		remove: (...name: string[]) => {
			const arr = this.getClassArray();
			this.setAttribute('class', arr.filter((n) => !name.includes(n)).join(' '));
		},
		toggle: (name: string, force?: boolean): boolean => {
			const arr = this.getClassArray();
			const exist = arr.includes(name);
			const add = force === undefined ? !exist : force;
			if (add) {
				if (!exist) {
					this.setAttribute('class', arr.concat(name).join(' '));
				}
				return true;
			} else {
				if (exist) {
					this.setAttribute('class', arr.filter((n) => n !== name).join(' '));
				}
				return false;
			}
		},
		contains: (name: string): boolean => {
			const arr = this.getClassArray();
			return arr.includes(name);
		},
		item: (index: number): string => {
			const arr = this.getClassArray();
			return arr[index];
		},
		[Symbol.iterator]: (): Iterator<string> => {
			const arr = this.getClassArray();
			let i = 0;
			return {
				next() {
					if (i < arr.length) {
						return {
							done: false,
							value: arr[i++],
						};
					} else {
						return {
							done: true,
							value: undefined,
						};
					}
				},
			};
		},
	};

	constructor(
		public readonly ownerDocument: KnnoDocument,
		public readonly nodeName: string,
		public readonly nodeType: number
	) {
		if (nodeName !== '#text') {
			this._attributes = new KnnoAttrs();
			this._childNodes = new KnnoList();
			this._style = new Map();
		}
	}

	get classList(): KnnoClassList {
		return this._classList;
	}

	getAttribute(qualifiedName: string): string | null {
		return this._attributes?.get(qualifiedName) ?? null;
	}
	hasAttribute(qualifiedName: string): boolean {
		return this._attributes?.has(qualifiedName) ?? false;
	}
	setAttribute(qualifiedName: string, value: string): void {
		if (!/[a-z_][a-z0-9_\-.]*/i.test(qualifiedName)) {
			throw new Error('String contains an invalid character');
		}
		this._attributes?.set(qualifiedName, value);
	}
	removeAttribute(qualifiedName: string): void {
		this._attributes?.delete(qualifiedName);
	}
	appendChild(node: KnnoElement): KnnoElement {
		this.childNodes.push(node);
		node.parentElement = this;
		return node;
	}
	hasChildNodes(): boolean {
		return (this._childNodes?.length ?? 0) > 0;
	}
	insertBefore(node: KnnoElement, child: KnnoElement | null): KnnoElement {
		const pos = child ? (this.childNodes.indexOf(child) ?? this.childNodes.length) : this.childNodes.length;
		this.childNodes.splice(pos, 0, node);
		node.parentElement = this;
		return node;
	}
	removeChild(child: KnnoElement): KnnoElement {
		const pos = this.childNodes.indexOf(child);
		if (pos >= 0) {
			const removed = this.childNodes.splice(pos, 1);
			removed[0].parentElement = null;
			return removed[0];
		} else {
			throw new Error('NotFound');
		}
	}
	addEventListener(): void {
		// do nothing
	}
	dispatchEvent(): boolean {
		return false;
	}
	removeEventListener(): void {
		// do nothing
	}

	get style(): KnnoStyle {
		return new Proxy(
			{
				setProperty: (key: string, value?: string, priority?: string) => {
					if (this._style) {
						if (value) {
							this._style.set(key, value + (priority === 'important' ? ' !important' : ''));
						} else {
							this._style.delete(key);
						}
						const str = Array.from(this._style.entries())
							.map((item) => item[0] + ':' + item[1])
							.join(';');
						this.setAttribute('style', str);
					}
				},
			},
			{
				set(target, p, newValue): boolean {
					if (typeof p === 'string') {
						target.setProperty(
							p.replaceAll(/[A-Z]/g, (s) => {
								return '-' + s.toLowerCase();
							}),
							newValue
						);
						return true;
					} else {
						return false;
					}
				},
			}
		);
	}

	get attributes(): KnnoAttrs {
		return this._attributes ?? new KnnoAttrs();
	}
	get childNodes(): KnnoList {
		return this._childNodes ?? new KnnoList();
	}
	get className(): string {
		return this._attributes?.get('class') ?? '';
	}
	set className(value: string) {
		this._attributes?.set('class', value);
	}
	get tagName(): string {
		return this.nodeName;
	}
	get children(): KnnoCollection {
		return new KnnoCollection(...this.childNodes.filter((c) => c.nodeType === 1));
	}
	get firstChild(): KnnoElement | null {
		return this.childNodes[0] ?? null;
	}
	get lastChild(): KnnoElement | null {
		return this.childNodes[this.childNodes.length - 1] ?? null;
	}
	get nextSibling(): KnnoElement | null {
		if (this.parentElement) {
			const pos = this.parentElement.childNodes.indexOf(this);
			return this.parentElement.childNodes[pos + 1];
		} else {
			return null;
		}
	}
	get previousSibling(): KnnoElement | null {
		if (this.parentElement) {
			const pos = this.parentElement.childNodes.indexOf(this);
			return this.parentElement.childNodes[pos - 1];
		} else {
			return null;
		}
	}
	get childElementCount(): number {
		return this.childNodes.filter((item) => item.nodeType === 1).length;
	}
	get firstElementChild(): KnnoElement | null {
		return this.childNodes.find((item) => item.nodeType === 1) ?? null;
	}
	get lastElementChild(): KnnoElement | null {
		return this.childNodes.findLast((item) => item.nodeType === 1) ?? null;
	}
	get parentNode(): KnnoElement | null {
		return this.parentElement;
	}
	get outerHTML(): string {
		const attrs = () => {
			const str = Array.from(this.attributes.entries())
				.map((entry) => `${entry[0]}="${encodeHTML(entry[1])}"`)
				.join(' ');
			return str ? ` ${str}` : '';
		};
		if (this.nodeType === 1) {
			if (monoTag.test(this.nodeName)) {
				return `<${this.nodeName.toLowerCase()}${attrs()}>`;
			} else if (this.nodeName === 'STYLE' || this.nodeName === 'SCRIPT') {
				return `<${this.nodeName.toLowerCase()}${attrs()}>${this.textContent}</${this.nodeName.toLowerCase()}>`;
			} else {
				return `<${this.nodeName.toLowerCase()}${attrs()}>${this.innerHTML}</${this.nodeName.toLowerCase()}>`;
			}
		} else return encodeHTML(this.textContent);
	}
	get innerHTML(): string {
		return this.childNodes.reduce((text, item) => text + item.outerHTML, '');
	}
	set innerHTML(text: string) {
		if (this.nodeName === 'STYLE' || this.nodeName === 'SCRIPT') {
			this.textContent = text;
			return;
		}
		this.childNodes.length = 0;
		const parser = /<(?<stop>\/?)(?<name>[^\s>]+)(?<attr>(?:"[^"]*"|'[^']*'|[^/>]|\/(?!>))*)(?<close>\/)?>/gm;
		let scan = 0;
		let m = parser.exec(text);
		const stack: KnnoElement[] = [];
		while (m) {
			if (m.index > scan) {
				const elem = stack.length > 0 ? stack[stack.length - 1] : this;
				elem.childNodes.push(this.ownerDocument.createTextNode(decode(text.slice(scan, m.index))));
			}
			const g = m.groups as Record<string, string>;
			if (g.stop) {
				if (g.stop && stack.length > 0 && g.name?.toUpperCase() === stack[stack.length - 1].nodeName) {
					const last = stack.pop() as KnnoElement;
					const elem = stack.length > 0 ? stack[stack.length - 1] : this;
					elem.appendChild(last);
				}
			} else {
				const newElem = this.ownerDocument.createElement(g.name);
				if (g.attr) setAttributes(newElem, g.attr);
				if (g.close || monoTag.test(g.name)) {
					const elem = stack.length > 0 ? stack[stack.length - 1] : this;
					elem.appendChild(newElem);
				} else {
					stack.push(newElem);
				}
			}
			scan = parser.lastIndex;
			m = parser.exec(text);
		}
		while (stack.length > 0) {
			const last = stack.pop() as KnnoElement;
			const elem = stack.length > 0 ? stack[stack.length - 1] : this;
			elem.appendChild(last);
		}
		if (scan < text.length) {
			this.childNodes.push(this.ownerDocument.createTextNode(decode(text.slice(scan))));
		}
	}
	get textContent(): string {
		if (this.nodeType === 1) {
			return this.childNodes.map((n) => n.textContent).join('');
		} else {
			return this._textContent ?? '';
		}
	}
	set textContent(value: string) {
		if (this.nodeType === 1) {
			this.childNodes.length = 0;
			this.childNodes.push(this.ownerDocument.createTextNode(value));
		} else {
			this._textContent = value;
		}
	}
}

export class KnnoDocument {
	body: KnnoElement;
	documentElement: KnnoElement;
	head: KnnoElement;

	constructor() {
		this.documentElement = this.createElement('html');
		this.body = this.createElement('body');
		this.head = this.createElement('head');
		this.documentElement.appendChild(this.head);
		this.documentElement.appendChild(this.body);
	}

	createElement(tagName: keyof HTMLElementTagNameMap): KnnoElement;
	createElement(tagName: string): KnnoElement;
	createElement(tagName: string): KnnoElement {
		return new KnnoElement(this, tagName.toUpperCase(), 1);
	}
	createTextNode(data: string): KnnoElement {
		const elem = new KnnoElement(this, '#text', 3);
		elem.textContent = data;
		return elem;
	}
}
